Skip to content

test: stabilise the Miniflare D1 suites (#190) - #192

Merged
Madraka merged 5 commits into
mainfrom
test/d1-stabilise-190
Nov 10, 2026
Merged

Madraka merged 5 commits into
mainfrom
test/d1-stabilise-190

Conversation

@Madraka

@Madraka Madraka commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #190.

Root cause

Both flakes in #190 are one upstream Miniflare 4.20260730.0 bug. Neither is a timing or statement-counting problem. The list-values "statement budget" failure (run 42789152142) ends in the same assertion as roles.test.ts (run 42793831919): message?.id === id at miniflare/src/plugins/core/proxy/fetch-sync.ts:147. Both are reached from D1Database#prepare/bind.

Miniflare runs prepare/bind as synchronous calls through a worker thread. The caller waits on a shared 0/1 flag. If the worker is preempted between its Atomics.store and its Atomics.notify, and the caller has already read the reply, the late notify wakes the next call before its reply exists. receiveMessageOnPort returns undefined and the assert throws. Every later call then reads the previous call's reply, which is the 1–3 ms cascade in roles.test.ts.

Upstream fix: cloudflare/workers-sdk#15552, merged into Miniflare 5 only. 4.20260730.0 is still npm latest, and no 4.x release carries the fix.

Harness answers

  • Instances: one Miniflare per test file. core-ts files that share a factory between two describes get one per describe, set back to undefined on dispose.
  • Disposal: always in afterAll. No reuse after dispose.
  • Concurrency: turbo --concurrency=2 runs 2 packages at once. Vitest uses cores − 1 workers (3 on CI), or 2 for @cloudhal/api. That is up to 5 files at once, each with its own workerd and proxy thread, on 4 vCPUs. The load makes the preemption more likely, but the bug is the handshake.

Fix

  • patches/[email protected] (through patchedDependencies):
    • backports #15552 (a per-request generation instead of the 0/1 flag);
    • closes the sync proxy pool's idle sockets after 1 s instead of 4 s, before workerd's 5 s idle timeout (#16445 A).
  • Guard that reproduces the race: packages/storage/test/miniflare-patch.test.ts plus two fixtures. A node -r preload, which Node also loads into worker threads, stalls both sides of the handshake on every other call. On real Miniflare, unpatched fails on the first stalled call with the CI assertion (3/3); patched completes 300 calls (3/3).
  • No retries and no serialisation. D1 files still run in parallel.
  • ADR-0012 addendum (2026-11-10) and an AGENTS.md note. Remove the patch with the 4.x pin, together with the sharp and undici overrides.

About the Miniflare patch

  • What it mirrors: cloudflare/workers-sdk#15552, merged 2026-09-16 into Miniflare 5 only. That covers the generation handshake in `SynchronousFetcher.fetch()` and its worker script. It also carries part A of #16445: `keepAliveTimeout`/`keepAliveMaxTimeout` 1000 on the sync worker's `Pool`, the same values #15781 gives Miniflare 5's runtime pool. Each hunk carries a `Cloudhal patch (cloudflare/workers-sdk#…)` comment.
  • Regenerating it on a Miniflare upgrade: the patch is hand-written against `dist/src/index.js`. There is no generator script, unlike Kumo's.
    1. If the new release already carries #15552, delete the patch and its `patchedDependencies` entry.
    2. Otherwise, run `pnpm patch miniflare@` and re-apply the two changes listed in the ADR.
    3. Run `pnpm patch-commit`, then rename the `patchedDependencies` entry in `pnpm-workspace.yaml`.
    4. Run the guard test below.
  • Test that fails when the patch isn't applied: `packages/storage/test/miniflare-patch.test.ts`. It forces the race on the installed Miniflare, so the unpatched build fails with `message?.id === id`. It runs in `pnpm check` and CI.
  • Where the repo points at it:
    • `pnpm-workspace.yaml`: a comment above `patchedDependencies`.
    • AGENTS.md: the `patches/` entry, and the Miniflare pin under Versions.
    • ADR-0012: the 2026-11-10 addendum, which covers the cause, the decision, the upgrade recipe and the removal condition.

Proof

CI stress (.github/workflows/d1-stress.yml): runs the (Miniflare) halves of storage, core-ts, api, schedules, comments and analytics with CI's turbo --concurrency=2. Each run is 10 jobs on Node 24 plus 10 on Node 26. It runs on workflow_dispatch and on PRs that touch the patch.

build load result
patched, final commit 5f85491 1 busy loop per vCPU 20/20 green (run)
patched, final patch (d62e9cf) 1 busy loop per vCPU 20/20 green (run)
patched (earlier version, also with #15781) CI's own 20/20 green (run)
unpatched baseline (temporary branch, now deleted) CI's own 20/20 green (run)
unpatched baseline 1 busy loop per vCPU 20/20 green (run)

Plainly: the race is too rare to trigger on demand. It hit CI twice on 2026-11-10, yet 40 unpatched stress jobs passed. So the stress runs show no regression; they do not prove the fix. The proof is the forced reproduction above, which is now the guard test, and it matches the CI stack exactly.

Local runs, one process at a time, final patch:

  • @cloudhal/storage: 5/5
  • @cloudhal/api: 5/5
  • roles.test.ts: 20/20
  • @cloudhal/core-ts: 0/5, every failure EADDRNOTAVAIL. This is the pre-existing local port problem below; the patch does not touch that code path.

CI duration: on the final commit the TypeScript jobs took 19m11s (Node 24) and 12m06s (Node 26); an earlier commit took 18m08s / 18m26s. The last four green main runs took 12–18 min. Nothing is serialised.

Not fixed here: local port exhaustion (older than this PR)

Async proxy calls go through dispatchFetch with undici reset: true. Each D1 query takes one TIME_WAIT port, which macOS holds for 30 s. One core-ts run fills 16,340 of 16,384 ephemeral ports, even at --maxWorkers=50%. Linux CI has not shown this.

I backported workers-sdk#15781 (keep-alive) first. core-ts then passed 5/5 locally. But one roles.test.ts run in 20 failed with ECONNRESET on two kept-alive sockets in a row. That can't be retried safely, because a write may already have run. A main-thread stall longer than 5 s reproduces it, but normal runs stall only about 0.5 s, so the cause is unsettled. That is a new CI risk, so I dropped the backport from this PR. The ADR records the details; it needs its own follow-up.

🤖 Generated with Claude Code

Madraka and others added 4 commits November 10, 2026 14:45
Both CI flakes in #190 were one Miniflare 4.20260730.0 bug: the
synchronous binding proxy (`D1Database#prepare`/`bind`) waits on a shared
0/1 flag, so a late notify from the previous reply wakes the next call
before its reply exists. The assert at fetch-sync.ts:147 fails and every
later call reads the previous reply (the 1-3 ms cascade in roles.test.ts).
The list-values "statement budget" failure has the same stack.

Every async proxy call also went through dispatchFetch with undici's
`reset`, one TIME_WAIT port per D1 query (2,000 queries left 2,002), which
ran macOS out of ephemeral ports under parallel D1 files.

No newer Miniflare 4.x exists, so patch the pin with the upstream fixes
merged for Miniflare 5 only: workers-sdk#15552 (generation handshake) and
#15781 (keep-alive dispatchFetch, 1 s idle), plus the same 1 s keep-alive
on the sync proxy's pool (#16445 A). No retries, D1 files stay parallel.

- patches/[email protected] via patchedDependencies
- packages/storage/test/miniflare-patch.test.ts fails without the patch
- .github/workflows/d1-stress.yml runs the Miniflare halves 20 times
- ADR-0012 addendum, AGENTS.md note

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
The #15781 backport made async proxy calls reuse sockets, but the timers
that close idle ones run on the test's main thread. A stall past workerd's
5 s idle timeout then reuses a closed socket: ECONNRESET, not safely
retryable (one local roles.test.ts run in 20; reproduced with a 5.5 s
stall). Drop it and keep the shipped `reset`; the patch is now #15552 plus
the sync pool's 1 s keep-alive, whose timers run on the proxy's own
thread.

The stress workflow adds one busy loop per vCPU: 20 unpatched runs at
CI's own load passed, so it did not tell the builds apart.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
The guard now forces the race on the real Miniflare: a `node -r`
preload (Node loads it in worker threads too) stalls the proxy thread
between its store and notify and the caller before its wait, on every
other call. Unpatched, the first stalled call fails the CI assertion;
patched, 300 prepare/bind calls stay in step.

ADR-0012: record the stress results (unpatched also passes 20 CI runs
without the forced stalls) and the local port exhaustion that stays
open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
`fileURLToPath(new URL(...))` failed typecheck (DOM vs Node `URL`);
build the paths with `path.join` like the other storage tests. ADR-0012
gains the recipe for regenerating the Miniflare patch on an upgrade.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Signed-off-by: Madraka <[email protected]>
@Madraka
Madraka merged commit dd26432 into main Nov 10, 2026
30 checks passed
@Madraka
Madraka deleted the test/d1-stabilise-190 branch November 10, 2026 13:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

CI: Miniflare D1 suites flake (fetch-sync assertion, list-values statement budget)

1 participant